Span<T> and ReadOnlySpan<T>
Stack-only ref structs for zero-allocation slicing
Span and ReadOnlySpan
Master modern .NET memory management with Span
Welcome π»
Welcome to one of the most transformative features introduced in modern .NET! Span
π€ Did you know? The introduction of Span<T> in .NET Core 2.1 led to massive performance improvements across the entire framework. The ASP.NET Core team reduced allocations by over 50% in some scenarios simply by adopting these types!
Core Concepts
What Are Span and ReadOnlySpan? π―
Span
ReadOnlySpanList<T> and IReadOnlyList<T>, but with far greater performance implications.
// Traditional approach - creates new arrays
int[] original = { 1, 2, 3, 4, 5, 6, 7, 8 };
int[] firstHalf = original.Take(4).ToArray(); // β Heap allocation!
int[] secondHalf = original.Skip(4).ToArray(); // β Another allocation!
// Modern approach - zero allocations
Span<int> span = original;
Span<int> firstHalfSpan = span.Slice(0, 4); // β
Just a view!
Span<int> secondHalfSpan = span.Slice(4, 4); // β
Another view!
The ref struct Constraint β‘
Both Span<T> and ReadOnlySpan<T> are declared as ref struct, which means they can only live on the stack. This is a deliberate design decision that enables their incredible performance characteristics:
| Can Do β | Cannot Do β |
|---|---|
| Use as local variables | Be fields in classes |
| Pass as method parameters | Be boxed to object |
| Return from methods | Be used in async methods |
| Use in stackalloc scenarios | Be captured by lambdas |
| Store in other ref structs | Implement interfaces |
π§ Memory Device: Think "STACK ONLY" - Span stays on the STACK, Only No exceptions, Lambdas Yield problems!
Memory Safety and the Compiler's Role π
The C# compiler performs extensive safety analysis to ensure Span<T> instances never outlive the memory they reference:
// β Compiler error: Cannot use local variable in method that returns to caller
Span<int> DangerousMethod()
{
Span<int> local = stackalloc int[10];
return local; // ERROR: Would reference invalid stack memory!
}
// β
Safe: Span references heap memory with longer lifetime
Span<int> SafeMethod()
{
int[] array = new int[10];
return array.AsSpan(); // OK: Array outlives the method
}
π‘ Tip: The compiler's safety analysis is called ref safety rules. These rules track the lifetime scope of memory and ensure references never dangle.
Unified Memory Access Pattern π
One of Span<T>'s superpowers is providing a single API for working with memory from different sources:
βββββββββββββββββββββββββββββββββββββββββββββββ β UNIFIED SPANAPI β βββββββββββββββββββββββββββββββββββββββββββββββ€ β β β π¦ Array π§ stackalloc β β β β β β ββββ Span ββββ β β β β β β β β π Native Memory β β β β Single API for slice, indexing, β β iteration, comparison, searching β βββββββββββββββββββββββββββββββββββββββββββββββ
void ProcessData(Span<byte> data)
{
// Same code works regardless of source!
for (int i = 0; i < data.Length; i++)
{
data[i] = (byte)(data[i] ^ 0xFF); // XOR operation
}
}
// All these work with the same method:
byte[] heapArray = new byte[100];
ProcessData(heapArray); // From heap
Span<byte> stackSpan = stackalloc byte[100];
ProcessData(stackSpan); // From stack
unsafe
{
byte* ptr = (byte*)Marshal.AllocHGlobal(100);
ProcessData(new Span<byte>(ptr, 100)); // From native memory
Marshal.FreeHGlobal((IntPtr)ptr);
}
Slicing Without Allocating πͺ
The killer feature of Span<T> is zero-allocation slicing. Traditional substring and array operations create new objects; Span<T> just adjusts pointers:
string text = "Hello, World! Welcome to Span<T>.";
// β Old way - allocates new strings
string part1 = text.Substring(0, 5); // Allocates "Hello"
string part2 = text.Substring(7, 5); // Allocates "World"
// β
New way - zero allocations
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> hello = span.Slice(0, 5); // Just a view
ReadOnlySpan<char> world = span.Slice(7, 5); // Another view
// Even better: range syntax (C# 8.0+)
ReadOnlySpan<char> hello2 = span[0..5]; // Equivalent to Slice(0, 5)
ReadOnlySpan<char> world2 = span[7..12]; // Equivalent to Slice(7, 5)
| Operation | Traditional | With Span | Savings |
|---|---|---|---|
| Substring | New string allocation | Pointer + length | ~40 bytes + data |
| Array segment | New array allocation | Pointer + length | ~24 bytes + data |
| Memory copy | Array.Copy | Direct memory access | Function call overhead |
Performance Characteristics β‘
Understanding the internal structure explains Span<T>'s performance:
// Simplified conceptual representation
public readonly ref struct Span<T>
{
private readonly ref T _reference; // Pointer to first element
private readonly int _length; // Number of elements
// Just 16 bytes on 64-bit systems!
// 8 bytes for reference + 8 bytes for length
}
MEMORY LAYOUT COMPARISON
Array (heap object):
ββββββββββββββββββββββββββββββββββββββββ
β Sync Block | Type Pointer | Length β 24 bytes overhead
ββββββββββββββββββββββββββββββββββββββββ€
β Element 0 | Element 1 | Element 2 ...β + actual data
ββββββββββββββββββββββββββββββββββββββββ
β
β Heap allocation required
Span (stack structure):
ββββββββββββββββββ¬ββββββββββ
β Reference β Length β 16 bytes total (stack)
ββββββββββ¬ββββββββ΄ββββββββββ
β
ββββ Points to existing memory
(no new allocation)
Conversion and Interoperability π
Span<T> provides rich conversion capabilities:
// From array to Span
int[] array = { 1, 2, 3, 4, 5 };
Span<int> span1 = array; // Implicit conversion
Span<int> span2 = array.AsSpan(); // Explicit method
Span<int> span3 = new Span<int>(array); // Constructor
// From array segment
Span<int> partial = array.AsSpan(1, 3); // Elements [1], [2], [3]
// From stackalloc
Span<int> stack = stackalloc int[10];
// To array (requires allocation)
int[] backToArray = span1.ToArray(); // Creates new array
// ReadOnlySpan conversions
ReadOnlySpan<int> readOnly = array; // Implicit
ReadOnlySpan<int> fromSpan = span1; // Span<T> β ReadOnlySpan<T>
// Span<int> notAllowed = readOnly; // β Cannot convert back!
π‘ Tip: Use ReadOnlySpanSpan<T> and ReadOnlySpan<T> to be passed, following the principle of least privilege.
Practical Examples
Example 1: High-Performance String Parsing π
Parsing CSV data traditionally creates many temporary string objects. With ReadOnlySpan<char>, we eliminate these allocations:
public static class CsvParser
{
// β Traditional approach - many allocations
public static string[] ParseLineOld(string line)
{
return line.Split(','); // Allocates array AND strings
}
// β
Modern approach - zero allocations (except result list)
public static List<string> ParseLine(ReadOnlySpan<char> line)
{
var results = new List<string>();
while (line.Length > 0)
{
int commaIndex = line.IndexOf(',');
if (commaIndex == -1)
{
// Last field
results.Add(line.ToString()); // Only allocate final strings
break;
}
// Extract field without allocation
ReadOnlySpan<char> field = line.Slice(0, commaIndex);
results.Add(field.ToString());
// Move to next field (no allocation)
line = line.Slice(commaIndex + 1);
}
return results;
}
// π₯ Even better: Parse without string allocation
public static int SumCsvIntegers(ReadOnlySpan<char> line)
{
int sum = 0;
while (line.Length > 0)
{
int commaIndex = line.IndexOf(',');
ReadOnlySpan<char> field = commaIndex == -1
? line
: line.Slice(0, commaIndex);
// Parse directly from span (no string allocation)
if (int.TryParse(field, out int value))
{
sum += value;
}
if (commaIndex == -1) break;
line = line.Slice(commaIndex + 1);
}
return sum;
}
}
// Usage
string csvData = "100,200,300,400,500";
int total = CsvParser.SumCsvIntegers(csvData.AsSpan());
Console.WriteLine($"Total: {total}"); // Output: Total: 1500
Performance impact: In benchmarks, the span-based approach is 3-5x faster and allocates 10-20x less memory than traditional string operations.
Example 2: Safe Buffer Manipulation π‘οΈ
Working with byte buffers for network protocols or file I/O becomes both safer and faster:
public class PacketProcessor
{
// Protocol: [2-byte length][4-byte type][payload]
public static bool TryReadPacket(
ReadOnlySpan<byte> buffer,
out int packetType,
out ReadOnlySpan<byte> payload)
{
packetType = 0;
payload = ReadOnlySpan<byte>.Empty;
// Validate minimum size
if (buffer.Length < 6)
return false;
// Read length (first 2 bytes) - zero allocation
short payloadLength = BitConverter.ToInt16(buffer.Slice(0, 2));
// Validate total size
if (buffer.Length < 6 + payloadLength)
return false;
// Read type (next 4 bytes) - zero allocation
packetType = BitConverter.ToInt32(buffer.Slice(2, 4));
// Extract payload - just a view, no copy
payload = buffer.Slice(6, payloadLength);
return true;
}
public static void WritePacket(
Span<byte> buffer,
int packetType,
ReadOnlySpan<byte> payload)
{
// Write length
BitConverter.TryWriteBytes(buffer.Slice(0, 2), (short)payload.Length);
// Write type
BitConverter.TryWriteBytes(buffer.Slice(2, 4), packetType);
// Write payload - efficient copy
payload.CopyTo(buffer.Slice(6));
}
}
// Usage
byte[] networkBuffer = new byte[1024];
int bytesReceived = 100; // From network read
if (PacketProcessor.TryReadPacket(
networkBuffer.AsSpan(0, bytesReceived),
out int type,
out ReadOnlySpan<byte> data))
{
Console.WriteLine($"Packet type: {type}, Data length: {data.Length}");
// Process data without any copying
}
Example 3: Stack Allocation for Temporary Buffers π
Combining stackalloc with Span<T> enables extremely fast temporary buffer creation:
public static class StringHelpers
{
// Reverse a string efficiently
public static string Reverse(string input)
{
if (string.IsNullOrEmpty(input))
return input;
// Allocate on stack for small strings (very fast!)
// Fall back to heap for large strings
Span<char> buffer = input.Length <= 128
? stackalloc char[input.Length]
: new char[input.Length];
// Copy and reverse
for (int i = 0; i < input.Length; i++)
{
buffer[i] = input[input.Length - 1 - i];
}
return new string(buffer);
}
// Convert to uppercase without allocation (reading only)
public static bool EqualsIgnoreCase(
ReadOnlySpan<char> left,
ReadOnlySpan<char> right)
{
if (left.Length != right.Length)
return false;
return left.Equals(right, StringComparison.OrdinalIgnoreCase);
}
// Build a formatted string with minimal allocations
public static string FormatCoordinates(double x, double y, double z)
{
// Stack-allocate buffer for formatting
Span<char> buffer = stackalloc char[100];
int pos = 0;
"Point(".AsSpan().CopyTo(buffer.Slice(pos));
pos += 6;
// Format each coordinate
x.TryFormat(buffer.Slice(pos), out int written);
pos += written;
buffer[pos++] = ',';
buffer[pos++] = ' ';
y.TryFormat(buffer.Slice(pos), out written);
pos += written;
buffer[pos++] = ',';
buffer[pos++] = ' ';
z.TryFormat(buffer.Slice(pos), out written);
pos += written;
buffer[pos++] = ')';
return new string(buffer.Slice(0, pos));
}
}
// Usage
string reversed = StringHelpers.Reverse("Hello"); // "olleH"
bool same = StringHelpers.EqualsIgnoreCase("Test".AsSpan(), "TEST".AsSpan());
string coords = StringHelpers.FormatCoordinates(1.5, 2.7, 3.9);
π§ Memory Device: "128 is great" - Use stackalloc for buffers 128 bytes or less to avoid stack overflow risks.
Example 4: Working with Memory for Async π
Since Span<T> cannot be used in async methods, .NET provides Memory
public class AsyncBufferProcessor
{
// β Cannot use Span in async method
// public async Task ProcessAsync(Span<byte> data) { ... }
// β
Use Memory<T> instead
public async Task<int> ProcessAsync(Memory<byte> data)
{
// Simulate async I/O
await Task.Delay(100);
// Convert to Span when doing actual work
Span<byte> span = data.Span;
int sum = 0;
foreach (byte b in span)
{
sum += b;
}
return sum;
}
// Pattern: Accept Memory<T>, work with Span<T>
public async Task WriteToStreamAsync(
Stream stream,
Memory<byte> buffer,
int count)
{
// Memory<T> can be stored across await
await stream.WriteAsync(buffer.Slice(0, count));
// After await, convert to Span for processing
Span<byte> written = buffer.Span.Slice(0, count);
LogWrittenData(written); // Synchronous processing
}
private void LogWrittenData(Span<byte> data)
{
// Work with Span in non-async context
Console.WriteLine($"Wrote {data.Length} bytes");
}
}
// Usage
var processor = new AsyncBufferProcessor();
byte[] buffer = new byte[1024];
int result = await processor.ProcessAsync(buffer);
π Span vs Memory Quick Reference
| Feature | Span<T> | Memory<T> |
|---|---|---|
| Storage | Stack only (ref struct) | Heap (regular struct) |
| Async support | β No | β Yes |
| Performance | β‘ Fastest | β‘ Fast (converts to Span) |
| Use case | Synchronous processing | Async/await scenarios |
| Conversion | N/A | memory.Span β Span<T> |
Common Mistakes β οΈ
Mistake 1: Trying to Store Span in a Class Field
// β WRONG - Compiler error!
public class DataProcessor
{
private Span<byte> _buffer; // ERROR: Cannot be a field
public DataProcessor(Span<byte> buffer)
{
_buffer = buffer; // Won't compile
}
}
// β
CORRECT - Use Memory<T> for storage
public class DataProcessor
{
private Memory<byte> _buffer; // OK: Memory can be stored
public DataProcessor(Memory<byte> buffer)
{
_buffer = buffer;
}
public void Process()
{
Span<byte> span = _buffer.Span; // Convert when needed
// Work with span...
}
}
Mistake 2: Using Span in Async Methods
// β WRONG - Compiler error!
public async Task ProcessAsync(Span<byte> data)
{
await Task.Delay(100); // ERROR: Span cannot cross await
ProcessData(data);
}
// β
CORRECT - Use Memory<T> for async
public async Task ProcessAsync(Memory<byte> data)
{
await Task.Delay(100); // OK: Memory can cross await
ProcessData(data.Span); // Convert to Span after await
}
private void ProcessData(Span<byte> data)
{
// Synchronous processing with Span
}
Mistake 3: Assuming Span Copies Data
// β οΈ DANGER - Modifying through span affects original!
int[] original = { 1, 2, 3, 4, 5 };
Span<int> span = original.AsSpan();
span[0] = 999;
Console.WriteLine(original[0]); // Output: 999 (not 1!)
// β
CORRECT - Use ToArray() to create a copy
int[] original = { 1, 2, 3, 4, 5 };
Span<int> span = original.AsSpan();
int[] copy = span.ToArray(); // Create independent copy
copy[0] = 999;
Console.WriteLine(original[0]); // Output: 1 (unchanged)
Console.WriteLine(copy[0]); // Output: 999
Mistake 4: Returning Stack-Allocated Spans
// β EXTREMELY DANGEROUS - Compiler prevents this!
Span<int> CreateBuffer()
{
Span<int> buffer = stackalloc int[10];
return buffer; // ERROR: Would reference invalid stack memory
}
// β
CORRECT - Return heap-allocated or parameter-based spans
Span<int> GetSlice(int[] array, int start, int length)
{
return array.AsSpan(start, length); // OK: array outlives method
}
int[] CreateInitializedArray()
{
Span<int> buffer = stackalloc int[10];
buffer.Fill(42);
return buffer.ToArray(); // OK: Converts to heap array
}
Mistake 5: Capturing Span in Lambdas or Closures
// β WRONG - Compiler error!
void ProcessItems(Span<int> items)
{
var query = items.Where(x => x > 0); // ERROR: Cannot capture Span
}
// β
CORRECT - Convert to array first, or avoid LINQ
void ProcessItems(Span<int> items)
{
// Option 1: Manual iteration
for (int i = 0; i < items.Length; i++)
{
if (items[i] > 0)
{
ProcessItem(items[i]);
}
}
// Option 2: Convert to array (if allocation is acceptable)
var array = items.ToArray();
var query = array.Where(x => x > 0);
}
Mistake 6: Excessive stackalloc Causing Stack Overflow
// β DANGER - May overflow stack!
void ProcessLargeData()
{
Span<byte> huge = stackalloc byte[1_000_000]; // 1 MB on stack! π₯
// ...
}
// β
CORRECT - Use threshold pattern
void ProcessData(int size)
{
const int StackAllocThreshold = 512; // Conservative limit
Span<byte> buffer = size <= StackAllocThreshold
? stackalloc byte[size] // Small: use stack
: new byte[size]; // Large: use heap
// Process buffer...
}
π‘ Tip: The typical stack size is 1 MB on Windows (64-bit). Keep stackalloc allocations under 128-512 bytes to be safe, especially in recursive or deeply nested code.
Key Takeaways π―
β
Span
β Stack-only constraint enables incredible performance but prevents use in async methods, as class fields, or with lambdas
β Slicing operations (Slice, indexers) create new views without copying dataβmodifications affect the underlying memory
β
Memory
β stackalloc with Span enables ultra-fast temporary buffers, but keep allocations small (β€128-512 bytes)
β Unified API works with arrays, stack memory, and native memory through a single, consistent interface
β
ReadOnlySpan
β BitConverter and parsing APIs work directly with spans, enabling zero-allocation data transformations
π Quick Reference Card
| Scenario | Use This | Why |
|---|---|---|
| Synchronous processing | Span<T> | Maximum performance |
| Read-only parameter | ReadOnlySpan<T> | Prevents modifications |
| Async/await | Memory<T> | Can cross await boundaries |
| Store in field | Memory<T> | Not a ref struct |
| Small temp buffer | stackalloc + Span | Zero allocation |
| Large buffer | ArrayPool or heap | Avoid stack overflow |
| String operations | ReadOnlySpan<char> | Avoid substring allocations |
| Byte protocols | Span<byte> | Direct memory access |
Common Operations
| Operation | Code |
|---|---|
| Create from array | array.AsSpan() |
| Slice range | span[start..end] |
| Get length | span.Length |
| Access element | span[index] |
| Copy data | source.CopyTo(dest) |
| Fill with value | span.Fill(value) |
| Find element | span.IndexOf(value) |
| Convert to array | span.ToArray() |
| Memory to Span | memory.Span |
π Further Study
Official Documentation: Span
Struct - Microsoft Docs - Comprehensive API reference and usage guidelinesPerformance Deep Dive: All About Span - MSDN Magazine - Detailed exploration of Span's implementation and performance characteristics
Memory
and Async Patterns : Memoryand Span - Best practices for choosing between Span and Memory in real-world applicationsusage guidelines